﻿// Project: Salient
// http://salient.codeplex.com
// 
// Copyright 2010, Sky Sanders <sky at skysanders.net>
// Dual licensed under the MIT or GPL Version 2 licenses.
// http://salient.codeplex.com/license
// 
// Date: April 25 2010 

#region

using System;
using System.IO;
using System.Web;
using System.Web.Security;
using System.Web.SessionState;

#endregion

namespace Salient.Web.Security
{
    /// <summary>
    /// SessionAuditorModule provides a mechanism for auditing the lifecycle
    /// of an ASP.Net session and/or an ASP.Net FormsAuthentication cookie in the interest
    /// of providing better monitoring and control of a user session. This is becoming more
    /// important as more and more web apps are single page apps that may not cycle a page for
    /// many minutes or hours.
    /// 
    /// Storing a token in the Asp.net Session was for many years the default authentication strategy.
    /// The are still valid applications for this techique although FormsAuthentication has far
    /// surpassed in security and functionality.
    /// 
    /// What I am providing is a manner in which to transparently monitor the expiration status of each
    /// by implementing a module that recognizes two virtual endpoints:
    /// 
    ///   http://mysite/.aspnetsession
    ///   http://mysite/.formsauthticket
    /// 
    /// By making a request to these urls you will be delivered a javascript date in numeric form that
    /// represents the expiration dateTime of either the current ASP.Net session, if any, or the current
    /// FormsAuthentication ticket expiration, if any.
    /// 
    /// If the requested item does not exists, zero is returned. Any value served by this module should
    /// be cast to a date and compared with Now. If less than you should take action. You should have
    /// taken action on the client before the session timed out, aided by the output of this module, but
    /// hey, nobody is perfect.
    /// </summary>
    /// TODO: it might be useful to return a jsob with the timeout value as well as the sessionid and/or 
    /// username. For instance, flash has a cookie bug and knowing the sessionid can help work around that.
    /// 
    /// TODO: get cookie names directly from providers/modules/configuration
    public class SessionAuditorModule : IHttpModule
    {
        // note: these must remain in sync with the string keys in the javascript
        ///<summary>
        ///</summary>
        public const string AspSessionAuditKey = ".aspnetsession";
        /// <summary>
        /// </summary>
        public const string EmbeddedJsPath = "/SessionAuditor_Embedded.js";
        ///<summary>
        ///</summary>
        public const string FormsAuthAuditKey = ".formsauthticket";

        #region IHttpModule Members

        /// <summary>
        /// Initializes a module and prepares it to handle requests.
        /// </summary>
        /// <param name="context">
        /// An <see cref="T:System.Web.HttpApplication"/> that provides access to the methods, properties, and events common to all application objects within an ASP.NET application 
        /// </param>
        public void Init(HttpApplication context)
        {
            // this is our audit hook. get the request before anyone else does
            // and if it is for us handle it and end. no one is the wiser.
            // otherwise just let it pass...
            context.BeginRequest += HandleAuditRequest;

            // this is as early as we can access session. 
            // it is also the latest we can get in, as the script handler is coming
            // right after and we want to beat the script handler to the request
            // will have to set a cookie for the next audit request to read in Begin request.
            // the cookie is used nowhere else.
            context.PostAcquireRequestState += SetAuditBugs;
        }

        /// <summary>
        /// Disposes of the resources (other than memory) used by the module that implements <see cref="T:System.Web.IHttpModule"/>.
        /// </summary>
        public void Dispose()
        {
        }

        #endregion

        private static void SetAuditBugs(object sender, EventArgs e)
        {
            HttpApplication app = (HttpApplication) sender;


            if (app.Request.Url.PathAndQuery.ToUpperInvariant().StartsWith(EmbeddedJsPath.ToUpperInvariant()))
            {
                using (
                    Stream jsStream = typeof(SessionAuditorModule).Assembly.GetManifestResourceStream("Salient.Web.Security.SessionAuditor.SessionAuditor.js"))
                {
                    if (jsStream != null)
                        using (StreamReader reader = new StreamReader(jsStream))
                        {
                            string js = reader.ReadToEnd();
                            // massage js
                            app.Response.Write(js);
                            app.Response.ContentType = "text/javascript";
                            app.Response.Flush();
                            app.Response.End();
                        }
                }
                return;
            }


            if ((app.Context.Handler is IRequiresSessionState || app.Context.Handler is IReadOnlySessionState))
            {
                HttpCookie sessionTimeoutCookie = new HttpCookie(AspSessionAuditKey);

                // check to see if there is a session cookie
                string cookieHeader = app.Context.Request.Headers["Cookie"];
                if ((null != cookieHeader) && (cookieHeader.IndexOf("ASP.NET_SessionId") >= 0) &&
                    !app.Context.Session.IsNewSession)
                {
                    // session is live and this is a request so lets ensure the life span
                    app.Context.Session["__________SessionKicker"] = DateTime.Now;
                    sessionTimeoutCookie.Expires = DateTime.Now.AddMinutes(app.Session.Timeout).AddSeconds(2);
                    sessionTimeoutCookie.Value = EpochTimeStamp(sessionTimeoutCookie.Expires).ToString();
                }
                else
                {
                    // session has timed out; don't fiddle with it
                    sessionTimeoutCookie.Expires = DateTime.Now.AddDays(-30);
                    sessionTimeoutCookie.Value = 0.ToString();
                }
                app.Response.Cookies.Add(sessionTimeoutCookie);
            }
        }

        private static void HandleAuditRequest(object sender, EventArgs e)
        {
            HttpContext context = ((HttpApplication) sender).Context;
            bool formsAudit = context.Request.Url.PathAndQuery.ToLower().StartsWith("/" + FormsAuthAuditKey);
            bool aspSessionAudit = context.Request.Url.PathAndQuery.ToLower().StartsWith("/" + AspSessionAuditKey);

            if (!formsAudit && !aspSessionAudit)
            {
                // your are not the droids i am looking for, you may move along...
                return;
            }

            double timeout;
            // want to know forms auth status
            if (formsAudit)
            {
                HttpCookie formsAuthCookie = context.Request.Cookies[FormsAuthentication.FormsCookieName];
                if (formsAuthCookie != null)
                {
                    FormsAuthenticationTicket ticket = FormsAuthentication.Decrypt(formsAuthCookie.Value);
                    timeout = EpochTimeStamp(ticket.Expiration);
                }
                else
                {
                    timeout = 0;
                }
            }
                // want to know session status
            else
            {
                // no session here, just take the word of SetAuditBugs
                HttpCookie sessionTimeoutCookie = context.Request.Cookies[AspSessionAuditKey];
                timeout = sessionTimeoutCookie == null ? 0 : Convert.ToDouble(sessionTimeoutCookie.Value);
            }

            // ensure that the response is not cached. That would defeat the whole purpose
            context.Response.Cache.SetExpires(DateTime.UtcNow.AddMinutes(-1));
            context.Response.Cache.SetCacheability(HttpCacheability.NoCache);
            context.Response.Cache.SetNoStore();
            // the money shot. a javascript date.
            context.Response.Write(timeout.ToString());
            context.Response.Flush();
            context.Response.End();
        }

        /// <summary>
        /// </summary>
        /// <param name="date"></param>
        /// <returns></returns>
        private static double EpochTimeStamp(DateTime date)
        {
            DateTime d1 = new DateTime(1970, 1, 1);
            DateTime d2 = date.ToUniversalTime();
            TimeSpan ts = new TimeSpan(d2.Ticks - d1.Ticks);

            return ts.TotalMilliseconds;
        }
    }
}